Optimistic Update와 롤백 설계

Optimistic Update와 롤백 설계

한눈에 보기

Optimistic Update는 성공 가능성이 높은 변경을 서버 응답 전에 UI에 반영한다. 빠르게 느껴지는 대신 클라이언트가 잠시 미래를 추측하므로 실패, 동시 mutation, background refetch, 서버 보정에 대한 복구가 필요하다. 적용 전 되돌릴 수 있는 작업인지 판단하고, query를 취소한 뒤 snapshot을 보관하며, 임시 변경을 적용하고, 오류 시 해당 mutation만 복구한 뒤 서버 데이터로 다시 검증한다.

목차

낙관적이라는 말의 의미

사용자가 todo 완료 checkbox를 누를 때 서버 응답을 기다린 뒤 UI를 바꾸면 왕복 지연만큼 늦게 반응한다.

sequenceDiagram
    participant U as User
    participant UI
    participant API
    U->>UI: 완료 클릭
    UI->>API: PATCH /todos/42
    API-->>UI: 200 OK
    UI-->>U: checkbox 변경

Optimistic Update는 순서를 바꾼다.

sequenceDiagram
    participant U as User
    participant UI
    participant API
    U->>UI: 완료 클릭
    UI-->>U: 즉시 checkbox 변경
    UI->>API: PATCH /todos/42
    alt 성공
      API-->>UI: 서버 결과
      UI->>UI: 결과 확정/재검증
    else 실패
      API--xUI: 오류
      UI->>UI: 복구 + 오류 안내
    end

클라이언트는 “이 요청은 대체로 성공할 것”이라고 가정하고 미래 상태를 먼저 보여 준다. 네트워크가 빨라진 것이 아니라 지연을 사용자 상호작용 뒤로 숨긴 것이다.

임시 상태다

optimistic cache는 서버 사실이 아니라 아직 확정되지 않은 예측이다. UI에서 pending 여부와 실패 복구 가능성을 잃지 않아야 한다.

어떤 작업에 Optimistic Update가 잘 맞는가

성공 확률이 높고 결과를 클라이언트가 쉽게 예측하며 실패 시 되돌릴 수 있는 작업이 적합하다.

작업 적합성 이유
좋아요 토글 높음 결과 단순, 실패 시 되돌리기 쉬움
todo 완료 높음 대상과 다음 상태가 명확
댓글 작성 중간 임시 ID와 moderation 실패 고려
프로필 이름 수정 중간 서버 normalization·중복 검사 가능
재고 예약 낮음 경쟁 결과를 클라이언트가 모름
결제 승인 매우 낮음 성공 불명확성과 중복 부수 효과
계정 삭제 매우 낮음 되돌림 어렵고 영향 범위 큼

적용 전에 질문한다.

  1. 서버 결과를 클라이언트가 정확히 예측할 수 있는가?
  2. 실패율은 충분히 낮은가?
  3. 사용자가 실패를 알아차리고 복구할 수 있는가?
  4. 다른 사용자의 동시 변경과 충돌할 수 있는가?
  5. 요청 결과가 불명확할 때 안전하게 재조회할 수 있는가?
  6. 작업이 멱등한가?

결제처럼 “응답을 못 받았지만 실제 승인됐을 수 있는” 작업은 화면을 무조건 이전 상태로 돌리면 거짓말이 된다. PROCESSING 상태와 서버 조회를 제공하는 편이 안전하다.

UI에만 임시 결과를 표시하는 단순한 방식

optimistic 결과가 한 컴포넌트에만 필요하면 cache를 직접 바꾸지 않고 mutation variables로 임시 행을 그릴 수 있다.

function TodoList() {
  const todosQuery = useQuery({
    queryKey: todoKeys.list(),
    queryFn: fetchTodos,
  });

  const addTodoMutation = useMutation({
    mutationFn: createTodo,
    onSettled: () =>
      queryClient.invalidateQueries({
        queryKey: todoKeys.list(),
      }),
  });

  return (
    <ul>
      {todosQuery.data?.map((todo) => (
        <TodoRow key={todo.id} todo={todo} />
      ))}
      {addTodoMutation.isPending && (
        <TodoRow
          key={`pending-${addTodoMutation.submittedAt}`}
          todo={{
            id: "pending",
            title: addTodoMutation.variables.title,
            status: "sending",
          }}
        />
      )}
    </ul>
  );
}

요청이 실패하면 pending 행을 실패 상태로 남겨 재시도 button을 제공할 수 있다.

{addTodoMutation.isError && (
  <li>
    <span>{addTodoMutation.variables.title}</span>
    <span role="alert">저장하지 못했습니다.</span>
    <button
      type="button"
      onClick={() =>
        addTodoMutation.mutate(addTodoMutation.variables)
      }
    >
      다시 시도
    </button>
  </li>
)}

이 방식은 cache rollback이 필요 없고 이해하기 쉽다. 여러 화면이 같은 optimistic 결과를 즉시 봐야 할 때 cache 직접 갱신을 고려한다.

cache를 직접 변경하는 전체 흐름

todo 완료 토글을 cache에 반영하는 예제를 보자.

type ToggleTodoVariables = {
  todoId: string;
  completed: boolean;
  requestId: string;
};

type ToggleContext = {
  previousLists: Array<
    [readonly unknown[], Todo[] | undefined]
  >;
};
function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation<Todo, Error, ToggleTodoVariables, ToggleContext>({
    mutationFn: toggleTodo,

    onMutate: async (variables) => {
      await queryClient.cancelQueries({
        queryKey: todoKeys.lists(),
      });

      const previousLists = queryClient.getQueriesData<Todo[]>({
        queryKey: todoKeys.lists(),
      });

      queryClient.setQueriesData<Todo[]>(
        { queryKey: todoKeys.lists() },
        (current) =>
          current?.map((todo) =>
            todo.id === variables.todoId
              ? {
                  ...todo,
                  completed: variables.completed,
                  syncStatus: "pending",
                }
              : todo,
          ),
      );

      return { previousLists };
    },

    onError: (_error, _variables, context) => {
      context?.previousLists.forEach(([key, data]) => {
        queryClient.setQueryData(key, data);
      });
    },

    onSuccess: (serverTodo) => {
      queryClient.setQueryData(
        todoKeys.detail(serverTodo.id),
        serverTodo,
      );
    },

    onSettled: async () => {
      await queryClient.invalidateQueries({
        queryKey: todoKeys.all,
      });
    },
  });
}

흐름을 나누면 다음과 같다.

  1. 관련 query의 진행 중 refetch를 취소한다.
  2. 변경 전 cache snapshot을 보관한다.
  3. optimistic 값을 모든 관련 cache에 적용한다.
  4. 실패하면 snapshot으로 복구한다.
  5. 성공 응답으로 확정 가능한 cache를 갱신한다.
  6. 성공·실패와 관계없이 서버 데이터로 재검증한다.

onMutate, onError callback 인자의 정확한 순서는 TanStack Query 버전에 따라 달라질 수 있으므로 실제 프로젝트 타입을 기준으로 확인한다.

진행 중 query를 먼저 취소하는 이유

optimistic update 직전에 목록 refetch가 진행 중이라고 하자.

T1: 기존 목록 refetch 시작
T2: 사용자가 todo 완료 클릭
T3: optimistic cache에서 completed=true
T4: T1의 오래된 응답 도착
T5: completed=false로 optimistic 결과 덮어씀

cancelQueries는 진행 중 fetch가 나중에 cache를 덮지 않게 한다.

await queryClient.cancelQueries({
  queryKey: todoKeys.lists(),
});

query function이 제공된 AbortSignal을 실제 fetch에 전달해야 네트워크 요청도 취소된다.

async function fetchTodos({ signal }: { signal: AbortSignal }) {
  const response = await fetch("/api/todos", { signal });
  if (!response.ok) throw new HttpError(response.status);
  return response.json() as Promise<Todo[]>;
}

신호를 사용하지 않아도 library cache 상태를 취소 처리할 수 있는 경우가 있지만 실제 I/O 중단까지 보장하려면 signal을 소비한다.

mutation 취소와 query 취소는 다르다

서버에 이미 전달된 mutation은 클라이언트가 화면을 닫았다고 자동 취소되지 않는다. 서버가 작업을 완료할 수 있으므로 멱등성과 결과 재조회가 필요하다.

롤백 snapshot만으로 부족한 동시 mutation

두 todo를 빠르게 연속 변경하면 각 mutation이 서로 다른 snapshot을 가진다.

초기 cache: A=false, B=false

M1 시작 snapshot: A=false, B=false
cache: A=true, B=false

M2 시작 snapshot: A=true, B=false
cache: A=true, B=true

이때 M1만 실패해 전체 snapshot으로 롤백하면 cache가 A=false, B=false가 되어 성공 가능성이 있는 M2의 optimistic 변경까지 지운다.

sequenceDiagram
    participant M1 as Mutation A
    participant C as Cache
    participant M2 as Mutation B
    M1->>C: A=true
    M2->>C: B=true
    M1--xC: 실패, 전체 snapshot 복원
    Note over C: B의 pending 변경도 사라짐

동시 mutation이 가능하다면 복구 단위를 해당 entity와 field로 좁힌다.

type ToggleContext = {
  todoId: string;
  previousCompleted: boolean;
};

onMutate: async (variables) => {
  await queryClient.cancelQueries({
    queryKey: todoKeys.detail(variables.todoId),
  });

  const previous = queryClient.getQueryData<Todo>(
    todoKeys.detail(variables.todoId),
  );

  queryClient.setQueryData<Todo>(
    todoKeys.detail(variables.todoId),
    (current) =>
      current
        ? { ...current, completed: variables.completed }
        : current,
  );

  return {
    todoId: variables.todoId,
    previousCompleted: previous?.completed ?? false,
  };
}

그래도 같은 todo의 같은 field를 빠르게 두 번 토글하면 순서 경쟁이 남는다. 선택지는 다음과 같다.

TanStack Query의 mutation scope 등 버전별 직렬화 기능을 사용할 수 있지만 UI 요구와 server concurrency를 함께 설계한다.

임시 ID와 서버 ID를 안정적으로 연결하기

생성 mutation은 서버 ID가 아직 없으므로 optimistic item에 client ID가 필요하다.

type PendingTodo = Todo & {
  clientId: string;
  serverId: string | null;
  syncStatus: "pending" | "synced" | "failed";
};
function createTodoVariables(title: string) {
  return {
    clientId: crypto.randomUUID(),
    requestId: crypto.randomUUID(),
    title,
  };
}

optimistic cache에는 clientId를 stable key로 사용한다.

queryClient.setQueryData<TodoView[]>(
  todoKeys.list(),
  (current = []) => [
    ...current,
    {
      clientId: variables.clientId,
      serverId: null,
      title: variables.title,
      completed: false,
      syncStatus: "pending",
    },
  ],
);

서버 성공 응답이 오면 같은 clientId 항목을 교체한다.

onSuccess: (created, variables) => {
  queryClient.setQueryData<TodoView[]>(
    todoKeys.list(),
    (current = []) =>
      current.map((todo) =>
        todo.clientId === variables.clientId
          ? {
              ...created,
              clientId: variables.clientId,
              serverId: created.id,
              syncStatus: "synced",
            }
          : todo,
      ),
  );
}

React key도 clientId를 유지하면 서버 ID 도착 시 행이 재마운트되지 않는다. 이는 리스트 key에 index를 쓰면 생기는 문제와 연결된다.

서버가 client-generated ID나 requestId를 UNIQUE로 받아 멱등하게 생성하면 timeout 후 재시도도 안전해진다.

성공 응답과 cache를 어떻게 합칠까

서버는 요청 값을 그대로 저장하지 않을 수 있다.

따라서 optimistic 객체를 그대로 “성공”으로 표시하기보다 mutation 응답을 authoritative data로 사용한다.

onSuccess: (serverTodo) => {
  queryClient.setQueryData(
    todoKeys.detail(serverTodo.id),
    serverTodo,
  );
}

목록도 정확히 갱신할 수 있으면 응답을 병합한다. filter와 sort 조건이 복잡하면 invalidate가 더 안전하다.

예를 들어 완료한 todo가 “미완료만 보기” 목록에서 사라져야 한다.

function applyTodoToList(
  list: Todo[],
  updated: Todo,
  filters: TodoFilters,
): Todo[] {
  const withoutTarget = list.filter(
    (todo) => todo.id !== updated.id,
  );

  if (!matchesFilters(updated, filters)) {
    return withoutTarget;
  }

  return sortTodos([...withoutTarget, updated], filters.sort);
}

서버의 filter semantics를 클라이언트에서 완전히 복제하면 두 구현이 어긋날 수 있다. optimistic 단계에서는 최소한의 시각 변화만 주고 성공 후 refetch하는 타협이 실용적이다.

부분 실패와 되돌릴 수 없는 작업

요청 하나가 여러 server side effect를 만들면 단순 rollback UI가 실제 상태를 설명하지 못할 수 있다.

가령 “주문 확정” 요청이 DB에는 성공했지만 응답 전 네트워크가 끊겼다.

클라이언트: timeout → 실패라고 판단
서버: 주문 CONFIRMED 커밋 완료
클라이언트 rollback: PENDING 표시

이 경우 이전 UI로 되돌리는 대신 확인 중 상태를 표시하고 requestId로 서버 결과를 조회한다.

type OrderUiStatus =
  | "pending"
  | "submitting"
  | "confirming-result"
  | "confirmed"
  | "failed";
if (isAmbiguousNetworkFailure(error)) {
  setStatus("confirming-result");
  const result = await fetchOrderByRequestId(requestId);
  reconcileWithServer(result);
}

결제, 재고, 외부 메시지처럼 irreversible하거나 결과 불명확성이 있는 작업은 optimistic success보다 progress UI가 적합하다.

또한 여러 항목 일괄 수정에서 일부만 실패할 수 있다. 전체 snapshot으로 되돌릴지 성공 항목을 유지할지 API 응답 모델과 함께 정한다.

type BulkUpdateResult = {
  succeeded: Array<{ id: string; version: number }>;
  failed: Array<{ id: string; code: string }>;
};

중복 클릭과 멱등성

optimistic UI가 즉시 바뀌면 사용자는 완료됐다고 느끼지만 빠른 더블 클릭이나 네트워크 재시도로 mutation이 중복될 수 있다.

클라이언트에서는 진행 중 button을 비활성화할 수 있다.

<button
  type="button"
  disabled={mutation.isPending}
  onClick={() => mutation.mutate(command)}
>
  {mutation.isPending ? "저장 중" : "저장"}
</button>

하지만 disabled만으로 서버 중복을 막을 수 없다.

서버에 idempotency key를 보낸다.

async function createTodo(command: CreateTodoCommand) {
  return api.post("/todos", command, {
    headers: {
      "Idempotency-Key": command.requestId,
    },
  });
}

서버는 같은 key와 같은 payload의 결과를 재사용하고 다른 payload 재사용은 거절한다. 자세한 내용은 재시도 가능한 API에 Idempotency-Key 적용하기를 참고할 수 있다.

오류 UI와 접근성

rollback만 하고 아무 안내도 하지 않으면 사용자는 checkbox가 되돌아간 이유를 모른다.

function TodoRow({ todo }: { todo: TodoView }) {
  return (
    <li aria-busy={todo.syncStatus === "pending"}>
      <TodoCheckbox todo={todo} />
      {todo.syncStatus === "pending" && (
        <span>저장 중</span>
      )}
      {todo.syncStatus === "failed" && (
        <span role="alert">
          저장하지 못했습니다.
        </span>
      )}
    </li>
  );
}

사용자가 입력한 댓글처럼 복구 가치가 있는 데이터는 실패 시 삭제하지 않고 failed 상태로 남겨 재시도와 편집을 제공한다.

<button onClick={() => retryComment(comment.clientId)}>
  다시 시도
</button>
<button onClick={() => editComment(comment.clientId)}>
  수정
</button>

화면 reader에 모든 background 상태를 과도하게 announce하지 않는다. 사용자 action 결과와 중요한 실패만 aria-live 또는 role=alert로 전달한다.

오류 toast만 띄우고 focus가 다른 화면으로 이동했다면 사용자가 놓칠 수 있다. 해당 항목 가까이에 inline error를 둔다.

테스트 시나리오

Optimistic Update는 성공 테스트보다 실패와 순서 테스트가 중요하다.

즉시 반영과 성공 확정

it("응답 전 todo를 완료 상태로 표시한다", async () => {
  const deferred = createDeferred<Todo>();
  server.toggleTodo.mockReturnValue(deferred.promise);

  const user = userEvent.setup();
  renderTodoList();

  await user.click(screen.getByRole("checkbox", { name: "테스트 작성" }));

  expect(
    screen.getByRole("checkbox", { name: "테스트 작성" }),
  ).toBeChecked();
  expect(screen.getByText("저장 중")).toBeVisible();

  deferred.resolve(completedTodo);
  expect(await screen.findByText("저장 완료")).toBeVisible();
});

실패 rollback

it("실패하면 이전 완료 상태로 복구하고 오류를 표시한다", async () => {
  server.toggleTodo.mockRejectedValue(new Error("network"));

  const user = userEvent.setup();
  renderTodoList({ completed: false });

  await user.click(screen.getByRole("checkbox", { name: "테스트 작성" }));

  expect(
    await screen.findByText("저장하지 못했습니다."),
  ).toBeVisible();
  expect(
    screen.getByRole("checkbox", { name: "테스트 작성" }),
  ).not.toBeChecked();
});

순서가 바뀐 응답

두 mutation을 시작한 순서와 완료 순서를 다르게 만든다.

const first = createDeferred<Todo>();
const second = createDeferred<Todo>();

server.toggleTodo
  .mockReturnValueOnce(first.promise)
  .mockReturnValueOnce(second.promise);

await toggle("todo-a");
await toggle("todo-b");

second.resolve(todoBCompleted);
first.reject(new Error("conflict"));

expect(readTodo("todo-a").completed).toBe(false);
expect(readTodo("todo-b").completed).toBe(true);

추가 시나리오:

운영에서 확인할 지표

optimistic UX가 실제로 이득인지 측정한다.

rollback이 5% 이상 자주 보인다면 빠른 느낌보다 UI가 흔들리는 불신이 더 클 수 있다. validation 실패가 많다면 client가 서버 규칙을 충분히 반영하지 못하고 있는지 확인한다.

로그에는 requestId, entity type, 오류 코드, duration을 남기고 댓글 내용이나 개인정보를 그대로 기록하지 않는다.

analytics.track("optimistic_mutation_settled", {
  operation: "todo.toggle",
  requestId,
  result: "rolled_back",
  errorCode: "VERSION_CONFLICT",
  durationMs,
});

정리

Optimistic Update는 cache를 빨리 바꾸는 코드가 아니라 임시 상태와 서버 확정 사이의 프로토콜이다.

낙관적 UI는 성공을 미리 확정하는 것이 아니라 성공을 예상해 임시로 보여 주고, 틀렸을 때 정직하게 복구할 수 있도록 만드는 설계다.

관련 노트와 참고 자료